課程:JavaScript 與 React 底層原理 第 20 堂:React 效能優化原理
62 React.memo 防禦 Re-render
想像你正在開發一個複雜的儀表板。當使用者在輸入框中打字時,整個頁面——包括那些顯示靜態數據的複雜圖表——都在瘋狂地重新渲染(Re-render)。雖然畫面上什麼都沒變,但你的風扇開始狂轉,開發者工具的 Profiler 顯示出一片火紅。你可能會想:「React 不是標榜虛擬 DOM 很高效嗎?為什麼數據沒變,它還是要重新跑一遍我的元件函數?」
這就是我們在上一部分提到的「父元件連帶渲染」效能損耗。在這一部分,我們將深入探討 React 提供的最強防禦工具:React.memo。
React.memo 的本質:一位盡責的門神
在預設情況下,React 的渲染邏輯是非常「激進」的。正如我們在 Topic 7 與 Topic 11.1 中學到的,一旦父元件的狀態(State)發生變化,React 會預設重新渲染該父元件下所有的子元件,而不去檢查這些子元件的 Props 是否真的改變了。這是一種「寧可錯殺,不可放過」的策略,確保 UI 永遠保持最新。
然而,當子元件計算量龐大或層級過深時,這種策略就會變成效能瓶頸。
<u>React.memo</u> 是一個高階元件(Higher-Order Component, HOC)。它的作用就像是在子元件門口放了一位「門神」。當父元件要求子元件重新渲染時,這位門神會攔下這個請求,比對一下:「這次傳進來的 Props,跟上次我記住的那些,真的一樣嗎?」
- 如果一樣:門神會直接拒絕進入,讓 React 複用上一次渲染的結果(記憶化的結果),直接跳過這個元件的執行環境建立與 Diffing 過程。
- 如果不同:門神才會放行,讓元件執行函數並產生新的虛擬 DOM。
語法結構
const MyComponent = React.memo((props) => {
/* 只有在 props 改變時才會執行此函數 */
return <div>{props.value}</div>;
});
運作機制:深入「淺比較」的底層邏輯
你可能會問:React.memo 是怎麼判斷 Props 有沒有改變的?它是把整個物件翻開來逐層檢查嗎?
答案是:淺比較(Shallow Comparison)。
React 底層使用 Object.is 演算法來比對新舊 Props 物件中的每一個屬性。這是一個極速的 O(1) 或 O(n) 操作(n 為屬性個數),因為它只看屬性的「值」或是「參考位址」。
原始型別 vs. 參考型別
為了理解為什麼 React.memo 有時會「失靈」,我們必須回溯到 JavaScript 的資料儲存機制。
1. 原始型別(Primitive Types)
對於數字(Number)、字串(String)、布林值(Boolean)等,Object.is 比較的是它們的真實數值。
// 第一次渲染 props: { count: 1 }
// 第二次渲染 props: { count: 1 }
Object.is(1, 1); // true -> React.memo 成功攔截,不重新渲染
2. 參考型別(Reference Types)
對於物件(Object)、陣列(Array)與函數(Function),Object.is 比較的是它們在記憶體中的地址(Address)。
// 第一次渲染 props: { user: { name: "Aria" } }
// 第二次渲染 props: { user: { name: "Aria" } }
const prevUser = { name: "Aria" };
const nextUser = { name: "Aria" };
Object.is(prevUser, nextUser); // false! 雖然內容相同,但它們是不同的物件實體
這就是為什麼當你把一個在父元件內定義的物件或函數當作 Props 傳入時,React.memo 往往會失效。
預測與發現:為什麼你的 memo 經常攔不住 Re-render?
讓我們看一個經典的失效範例。這是一個常見的陷阱,許多開發者在加上 React.memo 後發現效能沒提升,往往是因為忽略了 JavaScript 執行環境的行為。
程式碼範例:失效的防線
// 子元件使用 React.memo 包裹
const HeavyChild = React.memo(({ onClick, data }) => {
console.log("子元件渲染了!");
return <button onClick={onClick}>點我執行內容:{data.title}</button>;
});
// 父元件
const Parent = () => {
const [count, setCount] = React.useState(0);
// 陷阱 1:每次父元件渲染時,都會重新建立一個全新的物件實體
const config = { title: "我是設定檔" };
// 陷阱 2:每次父元件渲染時,都會重新建立一個全新的函數實體
const handleClick = () => {
console.log("點擊了按鈕");
};
return (
<div>
<h1>父元件計數器:{count}</h1>
<button onClick={() => setCount(c => c + 1)}>增加計數</button>
{/* 雖然 config 內容沒變,handleClick 邏輯也沒變 */}
{/* 但因為父元件每次執行時,這兩者的記憶體位址都變了 */}
{/* 導致 HeavyChild 的淺比較結果永遠為 false */}
<HeavyChild onClick={handleClick} data={config} />
</div>
);
};
為什麼會這樣?
請回想 Topic 1.1 執行環境(EC)。當 Parent 元件因為 setCount 而重新執行時,它會建立一個新的函數執行環境。在該環境中,程式碼會從頭跑一遍:
const config = { ... }會在堆疊(Heap)中開闢一塊新空間。const handleClick = () => { ... }也會被重新宣告。
對於 HeavyChild 的門神(React.memo)來說,它拿到的 onClick 參考位址變了,data 的參考位址也變了。它無法分辨「內容是否相同」,它只知道「位址變了」,所以它會判定 Props 已改變,進而觸發重新渲染。
結論: React.memo 必須配合 useCallback(穩定函數參考)與 useMemo(穩定物件/值參考)才能發揮最大效用。如果你的 Props 包含參考型別,單純使用 React.memo 可能只是在浪費 CPU 做無謂的淺比較。
自訂比較函數:當淺比較不夠用時
有時候,你確實無法保證父元件傳入的參考是穩定的(例如資料來自於第三方套件或複雜的變換),但你又非常確定只要物件內的特定欄位沒變,子元件就不該更新。
這時,React.memo 允許你傳入第二個參數:arePropsEqual。
語法範例
const MyComponent = React.memo(
(props) => { /* 渲染邏輯 */ },
(prevProps, nextProps) => {
// 回傳 true 代表 props 相等 -> 不重新渲染
// 回傳 false 代表 props 不相等 -> 觸發重新渲染
return prevProps.user.id === nextProps.user.id;
}
);
警語:小心自訂比較的成本
這看起來很誘人,但請務必小心。如果你在 arePropsEqual 裡進行深層比對(Deep Comparison,例如遞迴檢查整個大物件),這個比對過程本身的運算成本,可能比「直接重新渲染元件」還要高。
原則: 只有當你確信「比對邏輯的開銷」遠小於「元件重新渲染的開銷」時,才考慮使用自訂比較函數。此外,過多的自訂比對邏輯會增加程式碼維護的困難,容易產生因為漏比對某個屬性而導致 UI 不更新的 Bug。
效能優化策略:何時該使用 React.memo?
在開發社群中,有一種傾向是「幫所有元件都加上 React.memo」,這被稱為過度優化(Over-optimization)。實際上,這是不明智的。
為什麼不該全面使用?
- 記憶體成本:
React.memo需要在記憶體中快取(Cache)上一次的 Props 與上一次的渲染結果。 - 比較成本:每次父元件更新,React 都要跑一次
Object.is循環。如果一個元件的 Props 本來就幾乎每次都會變(例如動畫數值、滑鼠座標),那麼這個比較操作就是純粹的浪費。
適合使用的黃金場景
- 純展示型元件(Pure Components):給定相同的 Props 永遠回傳相同結果的元件。
- 渲染代價昂貴的元件:內部包含複雜計算、大陣列遍歷或是深層嵌套的元件。
- Props 結構簡單且穩定:例如只有幾個字串或數字作為參數,且父元件經常因為其他不相關的狀態而重新渲染。
- 高頻觸發重新渲染的父元件:如果父元件是一個容器,裡面管了很多狀態,但某個子元件只需要其中一個很少變動的小狀態。
一個簡單的判斷框架
當你考慮是否加上 React.memo 時,先問自己三個問題:
- 這個元件重新渲染一次會很慢嗎?(如果只有幾毫秒,可能不值得優化)
- 它的 Props 是參考型別嗎?如果是,我有沒有在父元件用
useMemo/useCallback保護它們? - 父元件是否經常在不影響這個子元件的情況下重新渲染?
如果以上答案皆為「是」,那麼 React.memo 就是你的最佳選擇。
實戰對比:觀察效能差異
讓我們透過一個具體的案例來看看有無 memo 的差別。
場景:列表過濾器
假設我們有一個清單,上方有一個搜尋框。搜尋框的文字改變時,會過濾清單。但清單下方有一個「版權資訊」元件,它跟搜尋完全無關。
// 沒有 memo 的元件
const Copyright = ({ year }) => {
console.log("Copyright 渲染了");
return <footer>© {year} All Rights Reserved.</footer>;
};
// 有 memo 的元件
const MemoizedCopyright = React.memo(({ year }) => {
console.log("MemoizedCopyright 渲染了");
return <footer>© {year} All Rights Reserved.</footer>;
});
const App = () => {
const [query, setQuery] = React.useState("");
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<List query={query} />
{/* 當 query 改變,App 重新渲染 */}
{/* Copyright 會跟著重新渲染,即使 year 沒變 */}
<Copyright year={2023} />
{/* MemoizedCopyright 會攔截渲染,因為 2023 === 2023 */}
<MemoizedCopyright year={2023} />
</div>
);
};
在這個例子中,當使用者快速輸入文字時,Copyright 會觸發數十次不必要的控制台紀錄。雖然這只是個簡單的頁腳,但想像一下如果這是一個包含數百個節點的圖表,效能差異就會非常明顯。
穩定參考:通往高效渲染的必經之路
透過 React.memo,我們建立了一道防線。但這道防線非常脆弱,它極度依賴於傳入的 Props 是否具有「參考穩定性」。如果你在子元件門口放了門神,卻在父元件每次執行時都給子元件換一副「長得一模一樣但地址不同」的新眼鏡,門神最終還是會讓所有請求過關。
這引出了一個關鍵問題:我們該如何在父元件重新渲染時,保住那些物件與函數的位址,不讓它們被重新建立?
接下來,我們將進入效能優化的核心戰場:useMemo 與 useCallback。我們將學習如何真正地穩定 Props 參考,讓 React.memo 的防守不再虛設,達成真正的精準渲染。